/**
 * $Id: Search.java 16 2009-04-08 14:25:06Z azeckoski $
 * $URL: http://genericdao.googlecode.com/svn/tags/generic-dao-0.9.17/src/main/java/org/sakaiproject/genericdao/api/search/Search.java $
 * Search.java - gendao - Apr 8, 2008 11:50:18 AM - azeckoski
 **************************************************************************
 * Copyright (c) 2008 Aaron Zeckoski
 * Licensed under the Apache License, Version 2
 * 
 * A copy of the Apache License, Version 2 has been included in this 
 * distribution and is available at: http://www.apache.org/licenses/LICENSE-2.0.txt
 *
 * Aaron Zeckoski (azeckoski@gmail.com) (aaronz@vt.edu) (aaron@caret.cam.ac.uk)
 */

package org.sakaiproject.genericdao.api.search;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * This is a simple class which allows the passing of a set of search parameters in a nice way<br/>
 * Example usage:<br/>
 * <code>Search s1 = new Search("title", curTitle); // search where title equals value of curTitle</code><br/>
 * <code>Search s2 = new Search("title", curTitle, Restriction.NOT_EQUALS); // search where title not equals value of curTitle</code><br/>
 * <code>Search s2 = new Search(<br/>
 *    new Restriction("title", curTitle),<br/> 
 *    new Order("title")<br/>
 * ); // search where title equals value of curTitle and order is by title ascending</code><br/>
 * <br/>
 * Most searches can be modeled this way fairly easily. There are many constructors to make
 * it easy for a developer to write the search they want inside the search constructor.<br/>
 * There are also some methods to allow easy construction of searches in multiple steps:
 * {@link #addOrder(Order)} and {@link #addRestriction(Restriction)} allow restrictions and orders
 * to be added after the search was constructed, they will correctly handle duplicate values as well.<br/>
 * Currently searches are completely conjunctive (all ANDs) or disjunction (all ORs)<br/>
 * <br/>
 * There is also an option to pass a search string as well which can contain
 * formatted text to be interpreted by whatever is using the search object<br/>
 * <br/>
 * Finally, there are a few methods to make it easier to unpack and work with the search object:
 * {@link #isEmpty()} and {@link #getRestrictionByProperty(String)} and {@link #getRestrictionsProperties()}
 * make it easier to get the restriction information out of the search object
 * 
 * @author Aaron Zeckoski (aaronz@caret.cam.ac.uk)
 */
public class Search {

    /**
     * the index of the first persisted result object to be retrieved (numbered from 0)
     */
    private long start = 0;
    public void setStart(long start) {
        this.start = start < 0 ? 0 : start;
    }
    public long getStart() {
        return start;
    }

    /**
     * the maximum number of persisted result objects to retrieve (or 0 for no limit)
     */
    private long limit = 0;
    public void setLimit(long limit) {
        this.limit = limit < 0 ? 0 : limit;
    }
    public long getLimit() {
        return limit;
    }

    /**
     * if true then all restrictions are run using AND, if false then all restrictions are run using OR <br/>
     * Currently searches are completely conjunctive (all ANDs) or disjunction (all ORs)
     */
    public boolean conjunction = true;
    /**
     * if true then all restrictions are run using AND, if false then all restrictions are run using OR
     * Currently searches are completely conjunctive (all ANDs) or disjunction (all ORs)
     */
    public boolean isConjunction() {
        return conjunction;
    }
    public void setConjunction(boolean conjunction) {
        this.conjunction = conjunction;
    }

    /**
     * Restrictions define limitations on the results of a search, e.g. propertyA > 100 or property B = 'jump'<br/> You
     * can add as many restrictions as you like and they will be applied in the array order
     */
    private Restriction[] restrictions = new Restriction[] {};
    /**
     * Restrictions define limitations on the results of a search, e.g. propertyA > 100 or property B = 'jump'<br/> You
     * can add as many restrictions as you like and they will be applied in the array order,
     * this is a copy and not the actual stored restrictions
     */
    public Restriction[] getRestrictions() {
        return copyArray(restrictions);
    }
    public void setRestrictions(Restriction[] restrictions) {
        this.restrictions = copyArray(restrictions);
    }

    /**
     * Orders define the order of the returned results of a search, You can add as many orders as you like and they will
     * be applied in the array order
     */
    private Order[] orders = new Order[] {};
    /**
     * Orders define the order of the returned results of a search, You can add as many orders as you like and they will
     * be applied in the array order,
     * this is a copy and not the actual stored orders
     */
    public Order[] getOrders() {
        return copyArray(orders);
    }
    public void setOrders(Order[] orders) {
        this.orders = copyArray(orders);
    }

    /**
     * Defines a search query string which will be interpreted into search params,
     * If not null this indicates that this is a string based "search"<br/>
     * The search string is just text - there is no required structure nor any fieldModifiers. It is a freeform string.<br/>
     * Effectively the semantics are that it can be implemented in a relational database using 
     * like clauses for the relevant text fields - or perhaps just submitted to lucene and see which entities match.<br/>
     * If this is being sent to lucene - things like order, and restrictions might actually be added to the 
     * lucene query in addition to the simple search string.
     */
    private String queryString = null;
    /**
     * Defines a search query string which will be interpreted into search params,
     * If not null this indicates that this is a string based "search"<br/>
     * The search string is just text - there is no required structure nor any fieldModifiers. It is a freeform string.<br/>
     * Effectively the semantics are that it can be implemented in a relational database using 
     * like clauses for the relevant text fields - or perhaps just submitted to lucene and see which entities match.<br/>
     * If this is being sent to lucene - things like order, and restrictions might actually be added to the 
     * lucene query in addition to the simple search string.
     */
    public String getQueryString() {
        return queryString;
    }
    public void setQueryString(String queryString) {
        this.queryString = queryString;
    }


    // CONSTRUCTORS

    /**
     * Empty constructor, 
     * if nothing is changed then this indicates that the search should return
     * all items in default order
     */
    public Search() {}

    /**
     * Copy constructor<br/>
     * Use this create a duplicate of a search object
     */
    public Search(Search search) {
        copy(search, this);
    }

    /**
     * Do a search using a query string<br/>
     * @param queryString a search query string,
     * can be combined with other parts of the search object
     * @see #queryString
     */
    public Search(String queryString) {
        this.queryString = queryString;
    }

    /**
     * Do a simple search of a single property which must equal a single value
     * 
     * @param property
     *           the name of the field (property) in the persisted object
     * @param value
     *           the value of the property (can be an array of items)
     */
    public Search(String property, Object value) {
        restrictions = new Restriction[] { new Restriction(property, value) };
    }

    /**
     * Do a simple search of a single property with a single type of comparison
     * 
     * @param property
     *           the name of the field (property) in the persisted object
     * @param value
     *           the value of the property (can be an array of items)
     * @param comparison the comparison to make between the property and the value,
     * use the defined constants from {@link Restriction}: e.g. EQUALS, LIKE, etc...
     */
    public Search(String property, Object value, int comparison) {
        restrictions = new Restriction[] { new Restriction(property, value, comparison) };
    }

    /**
     * Do a search of multiple properties which must equal corresponding values,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     */
    public Search(String[] properties, Object[] values) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i]);
        }
    }

    /**
     * Do a search of multiple properties which must equal corresponding values,
     * control whether to do an AND or an OR between restrictions,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     * @param conjunction if true then all restrictions are run using AND, 
     * if false then all restrictions are run using OR
     */
    public Search(String[] properties, Object[] values, boolean conjunction) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i]);
        }
        this.conjunction = conjunction;
    }

    /**
     * Do a search of multiple properties which are compared with corresponding values,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     * @param comparisons the comparison to make between the property and the value,
     * use the defined constants from {@link Restriction}: e.g. EQUALS, LIKE, etc...
     */
    public Search(String[] properties, Object[] values, int[] comparisons) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i], comparisons[i]);
        }
    }

    /**
     * Do a search of multiple properties which are compared with corresponding values,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     * @param comparisons the comparison to make between the property and the value,
     * use the defined constants from {@link Restriction}: e.g. EQUALS, LIKE, etc...
     * @param conjunction if true then all restrictions are run using AND, 
     * if false then all restrictions are run using OR
     */
    public Search(String[] properties, Object[] values, int[] comparisons, boolean conjunction) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i], comparisons[i]);
        }
        this.conjunction = conjunction;
    }

    /**
     * Do a search of multiple properties which are compared with corresponding values,
     * sort the returned results in ascending order defined by specific sortProperties,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     * @param comparisons the comparison to make between the property and the value,
     * use the defined constants from {@link Restriction}: e.g. EQUALS, LIKE, etc...
     * @param orders orders to sort the returned results by
     */
    public Search(String[] properties, Object[] values, int[] comparisons, Order[] orders) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i], comparisons[i]);
        }
        this.orders = copyArray(orders);
    }

    /**
     * Do a search of multiple properties which are compared with corresponding values,
     * sort the returned results in ascending order defined by specific sortProperties,
     * all arrays should be the same length
     * @param properties the names of the properties of the object 
     * @param values the values of the properties (can be an array of items)
     * @param comparisons the comparison to make between the property and the value,
     * use the defined constants from {@link Restriction}: e.g. EQUALS, LIKE, etc...
     * @param orders orders to sort the returned results by
     * @param firstResult the index of the first persisted result object to be retrieved (numbered from 0)
     * @param maxResults the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     */
    public Search(String[] properties, Object[] values, int[] comparisons, 
            Order[] orders, long firstResult, long maxResults) {
        restrictions = new Restriction[properties.length];
        for (int i = 0; i < properties.length; i++) {
            restrictions[i] = new Restriction(properties[i], values[i], comparisons[i]);
        }
        this.orders = copyArray(orders);
        this.start = firstResult;
        this.limit = maxResults;
    }

    /**
     * Defines a search which defines only a single restriction,
     * defaults to AND restriction comparison and returning all results
     * @param restriction define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     */
    public Search(Restriction restriction) {
        this.restrictions = new Restriction[] { restriction };
    }

    /**
     * Defines a search which defines only restrictions,
     * defaults to AND restriction comparisons and returning all results
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     */
    public Search(Restriction[] restrictions) {
        this.restrictions = copyArray(restrictions);
    }

    /**
     * Defines a search which defines only a single restriction and returns all items,
     * defaults to AND restriction comparisons
     * @param restriction define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param order define the order of the returned results of a search (only one order)
     */
    public Search(Restriction restriction, Order order) {
        this.restrictions = new Restriction[] { restriction };
        this.orders = new Order[] { order };
    }

    /**
     * Defines a search which defines restrictions and return ordering,
     * defaults to AND restriction comparisons and returning all results
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param order define the order of the returned results of a search (only one order)
     */
    public Search(Restriction[] restrictions, Order order) {
        this.restrictions = copyArray(restrictions);
        this.orders = new Order[] { order };
    }

    /**
     * Defines a search which defines restrictions and return ordering,
     * defaults to AND restriction comparisons and returning all results
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param orders define the order of the returned results of a search, 
     * You can add as many orders as you like and they will be applied in the array order
     */
    public Search(Restriction[] restrictions, Order[] orders) {
        this.restrictions = copyArray(restrictions);
        this.orders = copyArray(orders);
    }

    /**
     * Defines a search which defines only a single restriction and limits the returns,
     * defaults to AND restriction comparisons
     * @param restriction define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param order define the order of the returned results of a search (only one order)
     * @param start the index of the first persisted result object to be retrieved (numbered from 0)
     * @param limit the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     */
    public Search(Restriction restriction, Order order, long start, long limit) {
        this.restrictions = new Restriction[] { restriction };
        this.orders = new Order[] { order };
        this.start = start;
        this.limit = limit;
    }

    /**
     * Defines a search which defines restrictions and return ordering and limits the returns,
     * defaults to AND restriction comparisons
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param order define the order of the returned results of a search (only one order)
     * @param start the index of the first persisted result object to be retrieved (numbered from 0)
     * @param limit the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     */
    public Search(Restriction[] restrictions, Order order, long start, long limit) {
        this.restrictions = copyArray(restrictions);
        this.orders = new Order[] { order };
        this.start = start;
        this.limit = limit;
    }

    /**
     * Defines a search which defines restrictions and return ordering and limits the returns,
     * defaults to AND restriction comparisons
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param orders define the order of the returned results of a search, 
     * You can add as many orders as you like and they will be applied in the array order
     * @param start the index of the first persisted result object to be retrieved (numbered from 0)
     * @param limit the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     */
    public Search(Restriction[] restrictions, Order[] orders, long start, long limit) {
        this.restrictions = copyArray(restrictions);
        this.orders = copyArray(orders);
        this.start = start;
        this.limit = limit;
    }

    /**
     * Defines a search which defines restrictions and return ordering and limits the returns,
     * also specifies the types of restriction comparisons (AND or OR)
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param order define the order of the returned results of a search (only one order)
     * @param start the index of the first persisted result object to be retrieved (numbered from 0)
     * @param limit the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     * @param conjunction if true then all restrictions are run using AND, 
     * if false then all restrictions are run using OR
     */
    public Search(Restriction[] restrictions, Order order, long start, long limit, boolean conjunction) {
        this.restrictions = copyArray(restrictions);
        this.orders = new Order[] { order };
        this.start = start;
        this.limit = limit;
        this.conjunction = conjunction;
    }

    /**
     * Defines a search which defines restrictions and return ordering and limits the returns,
     * also specifies the types of restriction comparisons (AND or OR)
     * @param restrictions define the limitations on the results of a search, 
     * e.g. propertyA > 100 or property B = 'jump'<br/> 
     * You can add as many restrictions as you like and they will be applied in the array order
     * @param orders define the order of the returned results of a search, 
     * You can add as many orders as you like and they will be applied in the array order
     * @param start the index of the first persisted result object to be retrieved (numbered from 0)
     * @param limit the maximum number of persisted result objects to retrieve (or <=0 for no limit)
     * @param conjunction if true then all restrictions are run using AND, 
     * if false then all restrictions are run using OR
     */
    public Search(Restriction[] restrictions, Order[] orders, long start, long limit, boolean conjunction) {
        this.restrictions = copyArray(restrictions);
        this.orders = copyArray(orders);
        this.start = start;
        this.limit = limit;
        this.conjunction = conjunction;
    }

    // HELPER methods

    /**
     * @param restriction add this restriction to the search filter,
     * will replace an existing restriction for a similar property
     */
    public void addRestriction(Restriction restriction) {
        if (restrictions != null) {
            int location = contains(restrictions, restriction);
            if (location >= 0 
                    && location < restrictions.length) {
                restrictions[location] = restriction;
            } else {
                restrictions = appendArray(restrictions, restriction);
            }
        } else {
            restrictions = new Restriction[] {restriction};
        }
    }

    /**
     * @param order add this order to the search filter,
     * will replace an existing order for a similar property
     */
    public void addOrder(Order order) {
        if (orders != null) {
            int location = contains(orders, order);
            if (location >= 0 
                    && location < orders.length) {
                orders[location] = order;
            } else {
                orders = appendArray(orders, order);
            }
        } else {
            orders = new Order[] {order};
        }
    }

    /**
     * Convenient method to find restrictions by their property,
     * if there happens to be more than one restriction with a property then
     * only the first one will be returned (since that is an invalid state)
     * 
     * @param property the property to match
     * @return the Restriction with this property or null if none found
     */
    public Restriction getRestrictionByProperty(String property) {
        Restriction r = null;
        if (restrictions != null && property != null) {
            for (int i = 0; i < restrictions.length; i++) {
                if (property.equals(restrictions[i].property)) {
                    r = restrictions[i];
                    break;
                }
            }         
        }
        return r;
    }

    /**
     * @return a list of all the properties on all restrictions in this search filter object
     */
    public List<String> getRestrictionsProperties() {
        List<String> l = new ArrayList<String>();
        if (restrictions != null) {
            for (int i = 0; i < restrictions.length; i++) {
                l.add(restrictions[i].property);
            }         
        }      
        return l;
    }

    /**
     * Finds if there are any search restrictions with one of the given properties, 
     * if so it returns the first of the found restriction,
     * otherwise returns null
     * 
     * @param properties an array of the properties (e.g. 'name','age') to find a value for
     * @return the value OR null if none found
     */
    public Restriction getRestrictionByProperties(String[] properties) {
        Restriction togo = null;
        for (int i = 0; i < properties.length; i++) {
            String property = properties[i];
            Restriction r = this.getRestrictionByProperty(property);
            if (r != null) {
                togo = r;
                break;
            }
        }
        return togo;
    }

    /**
     * Finds if there are any search restrictions with one of the given properties, 
     * if so it returns the first non-null value in the found restrictions,
     * otherwise returns null
     *
     * @param properties an array of the properties (e.g. 'name','age') to find a value for
     * @return the value OR null if none found
     */
    public Object getRestrictionValueByProperties(String[] properties) {
        Object value = null;
        for (int i = 0; i < properties.length; i++) {
            String property = properties[i];
            Restriction r = this.getRestrictionByProperty(property);
            if (r != null) {
                if (r.getValue() != null) {
                    value = r.getValue();
                    break;
                }
            }
        }
        return value;
    }

    /**
     * @return true if this search has no defined restrictions and no orders
     * (i.e. this is a default search so return everything in default order),
     * false if there are any defined restrictions or orders
     */
    public boolean isEmpty() {
        boolean empty = false;
        if ((restrictions == null || restrictions.length == 0) 
                && (orders == null || orders.length == 0) 
                && queryString == null) {
            empty = true;
        }
        return empty;
    }

    /**
     * Resets the search object to empty state
     */
    public void reset() {
        restrictions = new Restriction[] {};
        orders = new Order[] {};
        conjunction = false;
        queryString = null;
        start = 0;
        limit = 0;
    }

    /**
     * Checks to see if an array contains a value,
     * will return the position of the value or -1 if not found
     * 
     * @param <T>
     * @param array any array of objects
     * @param value the value to check for
     * @return array position if found, -1 if not found
     */
    public static <T> int contains(T[] array, T value) {
        int position = -1;
        if (value != null) {
            for (int i = 0; i < array.length; i++) {
                if (value.equals(array[i])) {
                    position = i;
                    break;
                }
            }
        }
        return position;
    }

    /**
     * Append an item to the end of an array and return the new array
     * 
     * @param array an array of items
     * @param value the item to append to the end of the new array
     * @return a new array with value in the last spot
     */
    @SuppressWarnings("unchecked")
    public static <T> T[] appendArray(T[] array, T value) {
        Class<?> type = array.getClass().getComponentType();
        T[] newArray = (T[]) Array.newInstance(type, array.length + 1);
        System.arraycopy( array, 0, newArray, 0, array.length );
        newArray[newArray.length-1] = value;
        return newArray;
    }

    /**
     * Utility method to convert an array to a string
     * @param array any array
     * @return a string version of the array
     */
    public static String arrayToString(Object[] array) {
        StringBuilder result = new StringBuilder();
        if (array != null && array.length > 0) {
            for (int i = 0; i < array.length; i++) {
                if (i > 0) {
                    result.append(",");
                }
                if (array[i] != null) {
                    result.append(array[i].toString());
                }
            }
        }
        return result.toString();
    }

    /**
     * Make a copy of an array, will return null if given null
     * 
     * @param <T>
     * @param array an array of objects
     * @return a copy of the array
     */
    @SuppressWarnings("unchecked")
    public static <T> T[] copyArray(T[] array) {
        T[] copy = null;
        if (array != null) {
            Class<?> type = array.getClass().getComponentType();
            copy = (T[]) Array.newInstance(type, array.length);
            System.arraycopy( array, 0, copy, 0, array.length );
        }
        return copy;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return copy(this, null);
    }

    /**
     * Make a copy of a search object
     * @param original the search object to copy
     * @param copy the search object make equivalent to the original,
     * can be null to generate a new one
     * @return the copy of the original
     */
    public static Search copy(Search original, Search copy) {
        if (copy == null) {
            copy = new Search();
        }
        copy.setStart(original.getStart());
        copy.setLimit(original.getLimit());
        copy.setConjunction(original.isConjunction());
        copy.setQueryString(original.getQueryString());
        copy.setRestrictions( copyArray(original.getRestrictions()) );
        copy.setOrders( copyArray(original.getOrders()) );
        return copy;
    }

    @Override
    public boolean equals(Object obj) {
        if (null == obj)
            return false;
        if (!(obj instanceof Search))
            return false;
        else {
            Search castObj = (Search) obj;
            boolean eq = this.start == castObj.start
            && this.limit == castObj.limit
            && this.conjunction == castObj.conjunction
            && (this.queryString == null ? castObj.queryString == null : this.queryString.equals(castObj.queryString))
            && Arrays.deepEquals(this.restrictions, castObj.restrictions)
            && Arrays.deepEquals(this.orders, castObj.orders);
            return eq;
        }
    }

    @Override
    public int hashCode() {
        if (this.isEmpty())
            return super.hashCode();
        String hashStr = this.getClass().getName() + ":" + this.start + ":" + this.limit + ":" + this.conjunction + ":"
        + this.queryString + ":" + arrayToString(restrictions) + ":" + arrayToString(orders);
        return hashStr.hashCode();
    }

    @Override
    public String toString() {
        return "search::start:" + start + ",limit:" + limit + ",conj:" + conjunction + ",query:" + queryString 
        + ",restricts:" + arrayToString(restrictions) + ",orders:" + arrayToString(orders);
    }

}
